Skip to content

offload dns resolution off the green worker#598

Merged
kacy merged 3 commits into
mainfrom
green-dns-offload
Jul 27, 2026
Merged

offload dns resolution off the green worker#598
kacy merged 3 commits into
mainfrom
green-dns-offload

Conversation

@kacy

@kacy kacy commented Jul 27, 2026

Copy link
Copy Markdown
Owner

summary

getaddrinfo is synchronous and there is nothing to poll, so a name lookup made from a green task held the worker os thread for its whole duration. every other task pinned to that worker stalled behind it. sockets already yield through the epoll reactor, which left dns as the one blocking step on the common client path, since every grpc, http, and database dial starts with one.

the lookup now goes to a small pool of ordinary blocking threads while the calling task parks. a pool thread wakes it when the answer is ready, through the same park/wake handshake netpoll uses for socket readiness, so a wake that lands before the task finishes suspending is recorded as pending rather than lost. the pool is capped at four threads and grows only when nothing is idle, so a program that resolves one name at a time runs one thread and a program that never resolves under green starts none.

the os-thread backend is untouched. resolve checks the backend and calls to_socket_addrs inline there, where blocking a thread is what a thread is for and a pool would only add a handoff. a numeric address also skips the pool, because to_socket_addrs parses one in place with no syscall to block in and dialing a literal ip is common.

failure behaviour is unchanged. a resolver error and an empty answer both come back as no addresses, which green_connect turns into 0 and pith_dns_resolve into null, exactly as before. a panic inside a lookup is caught and reported as a failed lookup rather than stranding the parked task, and if no pool thread can be started at all the caller resolves inline rather than parking on an answer that will never come.

the measurement

a program with two green tasks under PITH_GREEN_WORKERS=1: one resolves 40 uncached names, the other runs a fixed cpu loop and reports when it finished. on one worker the cpu task can only overlap the lookups if the resolving task gives the worker back.

seven interleaved pairs, swapping only target/release/libpith_runtime.a between arms so the driver binary is identical:

                     cpu task finished at
before   1063  928  993  902  982  1017  939 ms
after     425  415  418  415  418   414  417 ms

the cpu task's own runtime, measured alone, is 413-418 ms. before the change it always finished last and always at roughly its own time plus the whole dns batch; after it finishes at its own time while the resolver is still working (the resolver finished at 903-1010 ms in those same runs). so the worker is genuinely free during a lookup rather than the lookups merely getting faster.

what was tested

  • cargo build --release at the repo root and cargo test --release -p pith-runtime, including six new unit tests for the pool: it answers a lookup, reports failure as no addresses, never exceeds its thread cap under a burst of jobs, stays inline outside a green task, short-circuits literal addresses to the same answer to_socket_addrs gives, and still rejects an out-of-range port through the literal path
  • make test: 99/99 examples, 261/261 native regressions, 261/261 self-hosted regressions, plus the invalid, parity, cli, and ir-contract checks
  • PITH_GREEN=1 make verify-green-corpus: 261/261 at the default worker count and again pinned to one worker (260 before this branch; the new case brings it to 261)
  • make docsite-check
  • the new tests/cases/test_dns_resolve_and_dial.pith prints identical output under os threads, green at one worker, and green at the default worker count

kacy added 3 commits July 27, 2026 03:30
getaddrinfo is synchronous and there is nothing to poll, so a lookup made
from a green task held the worker os thread for its whole duration and
every other task pinned to that worker stalled behind it. sockets already
yield through the epoll reactor, which left dns as the one blocking step
on the common client path: every grpc, http, and database dial starts
with one.

the lookup now goes to a small pool of ordinary blocking threads (four at
most, started on demand) while the calling task parks, woken by the pool
thread through the same park/wake handshake netpoll uses for socket
readiness. the os-thread backend is untouched — resolve() checks the
backend and calls to_socket_addrs inline there, where blocking a thread
is what a thread is for — and a numeric address skips the pool entirely,
since parsing one never blocks.

failure behaviour is unchanged: a resolver error and an empty answer both
come back as no addresses, which green_connect turns into 0 and
pith_dns_resolve into null, exactly as before. a panic inside a lookup is
contained and reported as a failed lookup rather than stranding the
parked task. if no pool thread can be started at all, the caller falls
back to resolving inline.
covers the path the resolver-pool change touches from pith: a successful
lookup, a dial whose lookup succeeds and whose connect is refused, and a
loopback round trip. it is hosts-file and loopback only, so it needs no
network and produces the same output on both backends — which is the
point, since the green run of this case goes through the pool and the
os-thread run resolves inline.
both the backend-choice section and the rough-edges list called out
getaddrinfo as the blocking step in front of a dial. describe the
resolver pool instead, and pick a different example for the native call
preemption cannot interrupt.
@kacy
kacy merged commit fa87f12 into main Jul 27, 2026
2 checks passed
@kacy
kacy deleted the green-dns-offload branch July 27, 2026 04:01
kacy added a commit that referenced this pull request Jul 27, 2026
* make the green backend the default on linux

green is faster on every shape this repo measures and the last blocker
(dns holding a worker) landed in #598, so a spawned task now runs as a
coroutine on the worker pool unless you say otherwise.

the default stays os threads everywhere else. the reactor is epoll and
eventfd, so macos and the bsds compile netpoll_fallback, which has no
reactor at all: a green task waiting on a socket there would hold its
worker for the whole wait. green is still reachable with PITH_GREEN=1 on
those platforms, just not the default.

PITH_GREEN now reads in both directions. 1/on/true force green, 0/off/false
force os threads, and anything else takes the platform default. the off
side is new and is the escape hatch for anyone the flip hurts, so it is
trimmed and matched case-insensitively. an unrecognized value falls to the
platform default rather than to os threads, so the only values that move
the backend are the ones spelled out in the match and a typo behaves
exactly like an unset variable.

parsing moved into a pure backend_from_env so it can be tested without
fighting the OnceLock that backend() caches into.

* pin both backends explicitly in the differential tests

with green as the linux default, a comparison whose reference side just
says `pith run` is green against green and passes for free. every green-*
target now runs its os-thread side under PITH_GREEN=0.

the same reasoning applies to coverage. `make test` on a linux box now
exercises only green, so the PITH_GREEN=0 path across the corpus had no
gate at all. verify-osthread-corpus mirrors verify-green-corpus for the
opt-out and ci runs both. memcheck grew a small second list for the cases
whose subject is the os-thread task machinery itself, so the flip does not
quietly stop testing the code they were written for.

* describe green as the default rather than the experiment

docs/concurrency.md drops the "off by default and still experimental"
framing. the backend-choice section now says green on linux, os threads
elsewhere, and PITH_GREEN=0 as the opt-out, and it keeps the caveats that
came with the flip rather than burying them: file i/o has no yield point
so a file read holds its worker and everything pinned to it; preemption is
a build-time opt-in where the kernel gave os threads preemption for free;
and task placement is first-resume luck.

docs/limitations.md had a list of what it would take to make green the
default. most of that is history now, so it reads as the caveats of the
current default instead.

the performance scoreboard labelled its concurrency rows "(green)"; the
os-thread rows are the ones that need a label now, so they carry
PITH_GREEN=0. the same for the bench README table and the examples and
module headers that showed PITH_GREEN=1 as the way to opt in.

* accept no/yes for the backend switch, and fix two stale strings

three small corrections on top of the flip.

`PITH_GREEN=no` fell to the platform default, which on linux is green — so
someone reaching for the escape hatch and writing the most natural word for
"off" got exactly what they were trying to turn off. no/n now mean os threads
and yes/y mean green, which keeps the rule (only spelled-out values move the
backend) while making the direction that costs something hard to miss.

the green corpus target claimed it "matches os-thread output". it compares
against a fixed expected file, which is the stronger check and the reason it
catches a drift both backends share. now that there is a sibling target that
really does run os threads, the old wording is worth correcting.

and the corpus is 261 cases, not 260, since the dns case joined it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant